#!/usr/bin/env python

import os.path
import operator
import sys

from pyfileutils.LockFile import LockFile

from PyXSE import ls
from PyXSE.Row import Row
from PyXSE.TableSchema import TableSchema

try:
    from xml.etree import cElementTree as ET
except ImportError:
    try:
        from xml.etree import ElementTree as ET
    except ImportError:
        try:
            import cElementTree as ET
        except ImportError:
            try:
                from elementtree import ElementTree as ET
            except ImportError:
                raise Exception("Could not import ElementTree")

class ColumnNotFoundError(Exception):

    def __init__(self, column_name):
        """Raises a ColumnNotFoundError.

        column_name: the name of the column which was not found.

        """
        Exception.__init__(self, "Column %s was not found" % (column_name))

class DuplicateError(Exception):

    def __init__(self, object, value):
        Exception.__init__(self, "Duplicate %s %s" % (object, value))

class Table:

    """Table provides an SQL-like interface to Row objects.

    The basic operations SELECT, INSERT, UPDATE, DELETE are provided,
    as well as a couple others for convenience.  These options act
    directly on XML, though SELECT returns a tuple of Row objects...
    which in turn are wrappers around XML files.

    """

    def __init__(self, table_folder):
        """Initializes a Table object.
        
        dbfolder: a string representing the path of the database folder.
        
        """
        self.name = os.path.basename(table_folder)
        valid_chars = """\
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_- """
        for c in self.name:
            if c not in valid_chars:
                es = "Table name [%s] includes invalid characters"
                raise ValueError(es % (self.name))
        self.folder = table_folder
        self.schema = TableSchema(self.folder)
        self.has_unique_columns = len(self.schema.list_unique_column_ids()) > 0

    def _list_rows(self):
        """Returns a list of row names."""
        x = ls(self.folder)
        z = [y for y in x if y.filename != self.schema.filename \
                         and y.basename != self.schema.filename]
        return z

    def _get_rows(self, where=[], limit=False):
        """Returns a generator of Row objects.

        where:    a sequence of callables that expect an entry object
                  as their only argument.
        limit:    optional, an integer. If given and greater than zero,
                  only this number of results or fewer are returned.

        """
        if limit is False:
            remaining = None
        limit = int(limit)
        if limit < 1:
            remaining = None
        else:
            remaining = limit
        for rowfile in self._list_rows():
            row = Row(rowfile)
            if self._test_row(row, where):
                yield row
                if remaining is not None:
                    remaining -= 1
                if remaining == 0:
                    return

    def _test_row(self, row, where=[]):
        """Tests that a row passes a set of WHERE callables.

        row:   a sequence of Row instances.
        where: a sequence of callables that expect an Row instance as
               their only argument.

        """
        for w in where:
            if not w(row):
                return False
        return True

    def select(self, where=[], sort_key=False, reverse=False, limit=False):
        """SELECTs Rows from this Table.

        where:    a sequence of callables that expect an entry object as
                  their only argument.
        limit:    optional, an integer. If given and greater than zero,
                  only this number of results or fewer are returned.
        sort_key: optional, a string representing an attribute to sort
                  by.  If this is not in Row.supported_properties, an
                  Exception will be raised.
        reverse:  optional, a boolean.  If True and sort_key is given,
                  the results are reversed.

        If sort_key and reverse are not given, select returns a
        generator of results.

        """
        rows = self._get_rows(where, limit)
        if not (sort_key and reverse):
            return rows
        if sort_key and not sort_key in self.schema.list_column_ids():
            raise ColumnNotFoundError(sort_key)
        if sort_key:
            rows = sorted(rows, key=operator.itemgetter(sort_key))
        if reverse:
            rows.reverse()
        return tuple(rows)

    def insert(self, values):
        """INSERTs Rows into this Table.

        values: a sequence containing values for each attribute in
                the supported_attributes sequence of this Table's
                schema.

        """
        ###
        # TODO: Modify to accept a mapping for 'values'.  This also entails
        #       adding DEFAULTs to columns.
        ###
        number_of_values = len(values)
        if not number_of_values:
            raise ValueError, "No values given"
        # check that we got the right number of values
        column_names = self.schema.list_column_ids()
        number_of_columns = len(column_names)
        if number_of_values < number_of_columns:
            raise ValueError, "Not enough values given"
        elif number_of_values > number_of_columns:
            raise ValueError, "Too many values given"
        z = zip(column_names, values)
        self._check_unique(dict(z)) # will raise an Exception if not unique
        root = ET.Element("entry")
        for name, value in z:
            se = ET.SubElement(root, name)
            se.text = value
        LockFile(dir=self.folder).write(ET.tostring(root))

    def _check_unique(self, row_dict, against_rows=False):
        """Checks that a dict of Row values is unique in the table.

        row_dict:     a mapping of row values.  Can be either a dict
                      or a Row.
        against_rows: an optional sequence that, if given, will be
                      used instead of self._get_rows().  This allows
                      checking against a filtered set of rows.

        """
        ###
        # Here, we could really benefit from some sort of indexing.  This will
        # come later.  For now, we at least don't scan all rows if there are no
        # UNIQUE columns defined.
        #
        # This introduces a slight race condition, which will also go away w/
        # indexing.
        ###
        unique_columns = self.schema.list_unique_column_ids()
        if not unique_columns:
            return
        if against_rows is False:
            rows = self._get_rows()
        else:
            rows = against_rows
        for table_row in rows:
            for unique_column in unique_columns:
                if table_row[unique_column] == row_dict[unique_column]:
                    raise DuplicateError(unique_column, row_dict[unique_column])

    def delete(self, where=[]):
        """DELETEs a Row from this Table.

        where: a sequence of callables that expect an entry object as
               their only argument.

        """
        for e in self.select(where=where):
            e.file.delete()

    def update(self, where=[], set=[]):
        """UPDATEs a Row.

        where: a sequence of callables that expect an entry object as
               their only argument.
        set:   a sequence of callables that expect an entry object as
               their only argument.

        """
        ###
        # There's not an obvious way to minimize the times we iterate through
        # the rows.  Maybe I'll think of something later.
        ###
        if not set:
            return
        applicable_rows = []
        non_applicable_rows = []
        if not where:
            applicable_rows = self._get_rows()
        else:
            for row in self._get_rows():
                if self._test_row(row, where):
                    applicable_rows.append(row)
                else:
                    non_applicable_rows.append(row)
        for row in applicable_rows:
            for s in set:
                s(row)
        for i in range(len(applicable_rows)):
            self._check_unique(applicable_rows[i],
                               applicable_rows[:i] + applicable_rows[i + 1:])
            self._check_unique(applicable_rows[i], non_applicable_rows)
        for row in applicable_rows:
            row.save_to_disk()

    def count(self, where=[]):
        """Returns an integer representing the number of existing rows.

        where: a sequence of callables that expect an entry object as
               their only argument.

        """
        ###
        # IDK about this function, it seems like something the programmer
        # could easily do themselves.
        ###
        if not where:
            return len(self._list_rows())
        return len([x for x in self._get_rows(where=where)])

